fix(automation): a screen the caller already answered no longer parks the run, and list_actions publishes a flow action's inputs - #15787
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ntrols Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 2 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 47f6166b0f0b783eb7f8919038a2aaee013dee2b && git checkout 47f6166b0f0b783eb7f8919038a2aaee013dee2b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6acb37eb9496c822655edcd056c5047b35b83865 2041951a18e94ada28aeb46b81d0297b0e22d72c && git checkout -B drift-repro 6acb37eb9496c822655edcd056c5047b35b83865 && git merge --no-ff 2041951a18e94ada28aeb46b81d0297b0e22d72c
node scripts/docs-audit/affected-docs.mjs --json 6acb37eb9496c822655edcd056c5047b35b83865
|
Clause-② contract review — PR #15787 (card #15705), head
|
| Construction | Door shape | Result |
|---|---|---|
Colliding primitive columns (subject, dueDate on the row), no caller params |
dispatchFlowAction: record + {...record, recordId, crmLeadId} |
paused |
| Caller genuinely overrides the colliding fields | same | continued, output carries the caller's values |
| Ambiguous — caller sends the same values the row holds | same | paused (resolves to not-caller) |
Record-change trigger shape: params === record, same object (record-change-trigger.ts:499) |
trigger | paused |
Same, after a JSON.parse(JSON.stringify(ctx)) round-trip (primitive columns) |
trigger | paused |
Two collision classes the PR does not name, both measured skipping a screen with no caller input:
X1 — dispatcher-synthesized id keys. The console's flow handler POSTs /api/v1/automation/{name}/trigger with {recordId, objectName, params} (objectui useConsoleActionRuntime.tsx:511-553, RecordDetailView.tsx:891); buildAutomationContext (domains/automation.ts:80-115) puts recordId and <objectName>Id into params and sets no context.record. seedFlowActionParams does the same on the actions door (recordId, <objectName>Id, recordIdParam). None of these is a record column, so the record leg cannot disprove them and callerSupplied answers true. Measured: a screen whose only required field is recordId (+ optional notes), interactive, no caller params → not paused, run completed with {recordId:'lead_1'}; same on the actions door for crmLeadId. The bound value is the launched record's own id, so this loses an optional-field step rather than binding a wrong value; no example flow declares a screen field with one of these names (the examples declare recordId as an isInput variable only). recordIdParam is the same class — derived, not run.
X2 — identity does not survive the durable store. callerSupplied proves the record leg by Object.is. suspended-run-store.ts:673/691 persists context as context_json and restores it by JSON.parse; resumeInternal continues with run.context (engine.ts:5412-5423), so a later screen in a wizard, entered after a durable resume, judges against de-identified objects. Measured: actions-door context after a JSON round-trip, all-optional screen with field tags colliding with the array column tags: ['a','b'], no caller params → skipped, output {tags:['a','b']}; the identical context without the round-trip → paused. Primitive columns are unaffected (row 5 above). Narrow: actions door or record-change trigger + a store wired + a later screen + a non-primitive colliding column + no other required field on that screen. The store itself was not wired in the run — the transform it applies was.
Both overstate one published sentence (PR body, changeset, flows.mdx): "A console run supplies none of the screen's fields … including when the subject record carries a column named like one of them." The console does supply recordId and the doors alias it; identity does not hold across a durable resume. Suggested: (a) tighten the sentence; (b) follow-up hardening — treat the dispatcher-seeded id keys as non-caller, and/or compare by value rather than identity. Neither touches the fix's mainstream path.
2. The three conditions and the escape hatches — each verified independently
- C1 (caller named one of this screen's fields): dev pin; mutation M2 reddens exactly it. Note: C1 counts
!== undefinedwhile presence usesisPresent, so{notes: ''}on an all-optional screen skips it (measured). Consistent with the resume door accepting an empty optional; an interactive run never sends it — noted, not a safety issue. - C2 (every
requiredanswered by the caller): requireddueDateanswered only by a declareddefaultValue, caller namedsubject→ paused; answered by a priorassignmentnode → paused; optionalnotestaken from the row while both required fields came from the caller → continued with the row's value. Mechanism: only caller-supplied names enter the bag, so the variable's source is irrelevant by construction. - C3 (
visibleWhen): the headless seam passes() => true, so every unanswered conditionalrequiredfires → pause. Dev pin; reading confirms. - Escape hatches: message-only (
hasFieldsfalse →headlessundefined →shouldPause = wantsPause, byte-identical to before);waitForInput: true(guardcfg.waitForInput !== true→headlessundefined; M3 reddens exactly this control);waitForInput: false→wantsPausefalse → pass-through, dev pin. Object-form screens return before the block.
3. The asymmetry with the resume door — sound
Both seams are the one validateScreenInputs → one isPresent; the only difference is the visibility probe argument (headless: () => true; resume: the evaluator, engine.ts:5661, where undefined/false leaves required alone). Measured: ' ' yields required at both seams; a required field behind an unevaluable visibleWhen is unenforced at resume and refuses headless. "Present" cannot drift — one function, one predicate, one call each.
4. Census — re-derived
executor.execute(in src:engine.ts:7407/7412(insideexecuteNode, timeout and direct arms) andengine.ts:2462(alias delegate,return target.execute(...), itself only reached throughexecuteNode). Nothing else.- Resume:
engine.ts:5420-5423—map:correlation →executeNodere-run, otherwisetraverseNextpast the node. A screen mints nomap:. Confirmed. - Subflow/map children re-enter
engine.execute(map-node.ts:171-181:params= the author'sinputmapping,record: itemwhen the item is a record) — same funnel. waitForInputreaders: spec zod,screen-input-contract.ts(docs),screen-nodes.ts,cli/i18n-extract.ts(static). One runtime reader.shouldPause:screen-nodes.tsonly. Onescreenexecutor registration.- Alias path driven:
registerNodeAlias('screen_legacy','screen')— supplied continues, unsupplied pauses. - One imprecision: "a trigger-started run carries no such params" — the record-change trigger sets
params: isolatedRecord, deliberately the same object asrecord(record-change-trigger.ts:494-499). It pauses because of the identity leg, not because params are absent — and that identity leg is what §1-X2 shows is store-fragile. Outcome correct today; sentence imprecise.
5. Mutations — four reproduced, no rebuild
Harness: git hash-object before and after the edit; git checkout HEAD --; hash back to the HEAD blob and empty git diff HEAD; under trap … EXIT INT TERM.
| Mutation | Blob | Red | Restore |
|---|---|---|---|
| M1 drop the caller-provenance leg | screen-input-contract.ts a988f82→5a68ca0 |
1 — record-columns control | a988f82, diff 0 |
| M2 drop condition 1 | a988f82→022a101 | 1 — all-optional control | a988f82, diff 0 |
M3 bag overrides waitForInput: true |
screen-nodes.ts db5be81→5ce9913 |
1 — explicit waitForInput: true control |
db5be81, diff 0 |
M4 cut the getFlow wire only |
domains/mcp.ts 3503c99→309deb1 |
1 — the wire pin; 11 others green | 3503c99, diff 0 |
No-rebuild claim verified: test → ../engine.js / ./index.js → ./screen-nodes.js → ../screen-input-contract.js; runtime test → ./http-dispatcher.js → ./domains/mcp.js → ../action-execution.js — all relative; service-automation's only vitest alias is @objectstack/platform-objects. Empirically, all four reds appeared with no build step between mutate and run.
6. Fix 2 degradation paths — confirmed by reading and 12/12 pins
typeof automation?.getFlow === 'function' (service without it → undefined), try/catch (throws → undefined), registry null → flow ?? undefined → summarizeFlowInputParams returns [] → no params key: today's answer, all three. Precedence if (out.length === 0) — an author's params wins outright. required: field?.required === true — from the screen field, never inferred from defaultValue. collectScreenFieldSpecs walks every screen node, first declaration wins. getFlow is optional on IAutomationService (automation-service.ts:529). judgeHeadlessScreen is not re-exported from the package index — internal surface.
7. Honesty audit — confirmed
- Both gates observed exit 3 in this partially built worktree, each printing
PREREQUISITE NOT MET(EXIT_PREREQUISITE_NOT_MET = 3,EXIT_PREREQ = 3in the scripts). NOT MEASURED; not counted as passes by the dev. plugin-approvals: with@objectstack/trigger-record-changeunbuilt →Failed to resolve entry for package "@objectstack/trigger-record-change", 1 failed file / no tests — the reported shape, reproduced. Afterturbo build --filter='@objectstack/plugin-approvals...'→ 37 files / 665 tests passed. Environmental diagnosis confirmed.
Re-run here: @objectstack/service-automation 110 files / 1317 tests passed; @objectstack/runtime 228 files / 3255 tests passed (vitest exit 0, output captured to file — a first attempt whose head-terminated pipeline hid the summary was discarded, not counted).
NOT MEASURED by this review: @objectstack/mcp and dogfood suites, eslint, typecheck, the 60 green gates, ADR-0087 (PM verified exit 0); the recordIdParam variant of X1 (derived only); the DB-backed store end-to-end (X2 was driven by applying the store's own JSON transform to the context, not by wiring the store).
Reviewer: domain:services PM seat (session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909), clause-② contract review.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code
…aking Both dispatch doors put the launched row's id into params under names that are not record columns — recordId and the camelCase object-id alias on the trigger door (which sets no context.record at all), plus the action's declared recordIdParam on the actions door. A screen field named like one of them read as caller-supplied on a run that supplied nothing, so an interactive console launch could skip the screen. Refuse the two derivable names outright and the third by value (identical to the row id). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
The trigger door seeds recordId and the camelCase object-id alias into params and sets no context.record, so 'a console run supplies none of the screen's fields' was not true as written; and a record-change trigger sets params to the SAME object as record, so it pauses on the identity leg rather than on absent params. Also states the durable-resume gap: a JSON-rehydrated context loses the identity a non-scalar column's comparison relies on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Clause-② contract review, round 2 — PR #15787 (card #15705), head
|
| Screen's only required field | Door | Result |
|---|---|---|
recordId (+ optional notes) |
console / trigger | paused |
crmLeadId (the alias) |
console / trigger | paused |
crmLeadNoteId for object crm_lead_note (multi-underscore alias, derived as the doors derive it) |
console / trigger | paused |
recordId + crmLeadId |
actions | paused |
leadRef carrying record.id (the recordIdParam shape, value leg) |
actions | paused |
The dev's 6 new pins pass (20/20 in the file). The alias derivation in callerSupplied is the same /_([a-z])/g → upper-case + Id both buildAutomationContext and seedFlowActionParams use, keyed off context.object, which both doors set exactly when they seed the alias.
2. The feature is not disabled
Genuine caller supplying subject + dueDate → continues, output carries the caller's values — on the console door and on the actions door (where subject also collides with a row column). The dev's own "trigger door: a genuine caller param still satisfies" pin passes. One pause-only cost to note, not in the PR text: a screen field literally named recordId is now permanently interactive — a genuine caller sending recordId: 'lead_1' still pauses (measured). Correct direction; worth a sentence.
3. Per-leg / per-door attribution — confirmed exactly
Harness as in round 1 (git hash-object before/after; git checkout HEAD --; hash back to HEAD blob ab91228 and empty git diff HEAD; under trap … EXIT INT TERM):
| Mutation | Red | Survives |
|---|---|---|
| drop only the two derivable name refusals | 2 — both trigger-door pins | the actions-door pin (on the value leg) and the recordIdParam pin |
| drop only the value leg | 1 — the recordIdParam pin |
both trigger-door pins and the actions-door pin |
| revert the row-id refusal entirely | 4 — all of the above | — |
So the honest reading holds: only the name legs cover the record-less trigger door; the actions door is additionally guarded by value. If the name legs were ever removed, the actions door would stay guarded and the trigger door would not — and the two trigger-door pins are what would catch it.
4. The declared residual — it skips, not pauses
PR body: "recordIdParam naming a key the record lacks while recordIdField names a non-id column is the one row-id shape the value leg cannot see. Pause-only, like the rest."
Driven with the authentic bag (seedFlowActionParams({recordIdField:'token', recordIdParam:'sessionToken'}, {record:{id:'lead_1', token:'tok_9', …}}) → {…record, recordId:'tok_9', crmLeadId:'tok_9', sessionToken:'tok_9'}, printed from the real producer): a screen whose only required field is sessionToken (+ optional notes), interactive, no caller params → not paused; run completed with {sessionToken:'tok_9'}. Mechanism: recordId/crmLeadId are refused by name, but sessionToken is not the alias, its value 'tok_9' is not record.id ('lead_1'), and the record lacks the key → callerSupplied answers true. The failure direction of a missing refusal is skip; only the refusals themselves fail toward pausing.
Controls: the same shape with the default recordIdField (sessionToken:'lead_1') → paused (value leg); recordIdField:'token' + recordIdParam:'token' (a key the record has) → paused (record leg).
Realism: no declaration in the repo has the residual shape. sys_session pairs recordIdField:'token' with recordIdParam:'token' (record leg refuses); every other recordIdParam (sys_team, sys_invitation, sys_member, sys_user, sys_organization, the examples) uses the default recordIdField (value leg refuses). So there is no live instance — but the sentence is false in the direction the acceptance rule turns on.
Smallest close: in callerSupplied, also refuse a value Object.is-equal to params.recordId — the dispatcher seeds recordId with the actual row id under any recordIdField, so the recordIdParam key always carries that same value; a genuine caller sending the row id as a screen value only loses the skip (pause-only). Keep the record.id comparison too (a caller can override params.recordId). Then correct the sentence. Pin: the token/sessionToken shape above.
5. The three prose corrections — verified
- (a) "a trigger-started run carries no such params" → the body now credits the identity leg ("sets
paramsto the same object it sets asrecord… every key is identity-equal"), and it is pinned (record-change trigger shape: params IS the record, and it still pauses, assertingcompanyis in the bag). - (b) "a console run supplies none of the screen's fields" — zero occurrences at HEAD in the changeset and
flows.mdx; the body's replacement section names both seeds and the record-less trigger door correctly. - (c) X2: the durable-store limit is stated in the body, the changeset,
flows.mdxand thecallerSupplieddoc comment (scalar columns and un-paused runs unaffected; remedy is value comparison; filed as service-automation: a caller-provenance check that proves its negative byObject.isis defeated by the durable store, which restores the context throughJSON.parse#15812). The "Interactive runs are unchanged" paragraph in the changeset and docs is immediately followed by the gap paragraph, so no reader gets the unqualified claim alone. The one sentence that now claims something false is the §4 "pause-only".
6. The origin/main merge — clean on every axis
git show --remerge-diff is empty (no edits beyond auto-merge). Diff against the origin/main parent = exactly the PR's 9 files. Diff against the PR-tip parent = 104 files = main's own 24-commit set (104). HEAD^2 = the new merge-base 3508869ff. The PR's content diff against the new base is byte-identical to the pre-merge diff against the old base (0 lines of difference — main touched none of the 9 files). No sibling file was attributed to this branch.
7. Honesty spot-checks — confirmed
check:dual-build-cjs-loadsandcheck:published-readme-exportsobserved exit 3 at the new head, each printingPREREQUISITE NOT MET. NOT MEASURED.- "Cannot find package
@objectstack/spec/contracts" in a fresh worktree:engine.ts:14value-importsRESUME_AUTHORITY_SERVICEfrom@objectstack/spec/contracts, which service-automation resolves throughdist(its only vitest alias is@objectstack/platform-objects). Unbuilt-closure trap, not a real failure — the dev's reading is right.
Re-run here: @objectstack/service-automation full suite 110 files / 1323 tests passed (vitest exit 0, output captured to file, 0 FAIL lines).
Limits / NOT MEASURED by this round: every reading used the round-1 dist (built at the pre-merge base) — main's merge touched packages/spec/src/automation/flow.zod.ts only to add a duplicate-edge-id superRefine, immaterial to these readings, and no build was run on the contended box; @objectstack/runtime not re-run (no PR-side runtime change between the two heads — the PR-side diff of packages/runtime is empty); eslint, typecheck, the 60 green gates, @objectstack/mcp / dogfood / approvals suites; X2 (#15812) not re-driven; round-1 items not re-run, per the brief.
Reviewer: domain:services PM seat (session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909), clause-② contract review, round 2.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code
…ord.id A non-default recordIdField makes the seeded row id a column other than id, so an action's declared recordIdParam gave that value a third key the record does not carry: not a column, not record.id, not a derivable name. It read as caller-supplied and the screen was SKIPPED, not paused, on a launch that supplied nothing. The dispatcher seeds the same row id under every id key it knows, so params.recordId (and the camelCase alias) recover it without knowing the action-level name. Also states the accepted cost: a screen field named recordId or the object-id alias is always collected interactively. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
The token pin is satisfied by any of the three row-id candidates, so on its own it pinned the leg but not its parts: dropping params.recordId alone left all 21 green. Three fixtures now give each candidate a case only it can answer, by letting a record column shadow the other seed keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Clause-② contract review, round 3 — PR #15787 (card #15705), head
|
| Construction | Door | Result |
|---|---|---|
only-required sessionToken (+ optional notes), authentic bag {…record, recordId:'tok_9', crmLeadId:'tok_9', sessionToken:'tok_9'} |
actions | paused |
only-required sessionToken |
console / trigger | paused (the key is never seeded there) |
only-required recordId |
console / trigger | paused (name) |
Mechanism confirmed by reading: params.recordId carries the dispatcher's row id under any recordIdField, so 'tok_9' is now Object.is-equal to a candidate and refused. The dev's 24 pins pass at the new head.
2. The widening — sound; the stated cost is imprecise in both directions
Adding the alias's value and record.id as candidates fails toward pausing on every shape I could build; on the merits it is fine. Feature alive: a genuine caller supplying subject + dueDate continues with its values on the actions door.
The cost claim (body, changeset, flows.mdx): "a screen field named recordId or the object-id alias is now always collected interactively, even from a headless caller, and so is any field whose value happens to equal the launched row's id." Measured:
- Accurate for the central case: a required field whose value equals the row id, genuinely supplied → paused.
- Overstated (safe direction): an optional field whose value equals the row id, with another required field supplied → continued, and the value bound (
{subject:'x', parentId:'lead_1'}in the output). The same holds for an optional field namedrecordId. "Always collected interactively" is true only of required fields; an optional one is simply not counted toward condition 1. - Understated (pause-only, contrived): the refusal set is "equal to whatever the bag carries under
recordId, the alias, orrecord.id", which is the launched row's id on every real door unless the caller or the schema redefines those keys. Three such shapes measured, all paused: console door with the caller overridingparams.recordId = 'X'and sendingref: 'X'; console door with the caller overridingcrmLeadId = 'Y'and sendingparent: 'Y'; actions door where the record carries arecordIdcolumn'shadow'and the caller sendsref: 'shadow'. None of those values is the launched row's id.
Why I do not read this as blocking: the sentence is true for every unmodified door; the shapes it omits require a self-inconsistent caller (overriding the id keys with a different value) or a column literally named recordId/<object>Id — the same shadowing the boundary section already describes — and every omitted case is a pause. Precise wording, one sentence: "a field is never treated as caller-supplied when it is named recordId or <object>Id, or when its value equals what the bag carries under recordId, <object>Id, or record.id (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen."
3. Candidate discrimination — real, not asserted
All four, each proved on disk (git hash-object before/after), restored to HEAD blob 34585de with an empty git diff HEAD, under trap … EXIT INT TERM:
| Mutation | Red | On exactly |
|---|---|---|
drop the params.recordId candidate |
1 | "only params.recordId can refuse this one — the record shadows the alias key" |
| drop the alias VALUE candidate | 1 | "only the alias VALUE can refuse this one — the record shadows recordId" |
drop the record.id candidate |
1 | "only record.id can refuse this one — object-less action, and the record shadows recordId" |
| drop the whole value leg | 5 | both recordIdParam pins + the three fixtures above |
23 of 24 stay green under each single-candidate drop, so no candidate is answerable by another fixture. The disclosed "was 0 red before the fixtures" is consistent by construction — the token pin seeds 'tok_9' under all three keys, so any one candidate satisfies it — reasoned, not re-measured.
4. The boundary — stated as a skip; a second construction exists
The section says the remaining conjunction "skips rather than pauses — said plainly", and it does: object-less action, record carrying a recordId column, recordIdField: 'token', recordIdParam: 'sessionToken', authentic bag {…record, recordId:'shadow', sessionToken:'tok_9'} (no alias seeded — '', '*' and 'global' are the object-less keys) → run completed with {sessionToken:'tok_9'}. Not softened.
Finding — a second construction, same class: the alias candidate is lost not only when no object is bound but also when the record shadows the alias key itself. Object-bound crm_lead, record {id, token:'tok_9', recordId:'shadow', crmLeadId:'shadow2'}, same recordIdField/recordIdParam, authentic bag {…record, sessionToken:'tok_9'} → run completed with {sessionToken:'tok_9'}. Controls: shadow only recordId on an object-bound action → paused (alias value); object-less with the default recordIdField → paused (record.id). So the boundary is "every candidate shadowed", of which object-lessness is one way to lose the alias and a <object>Id column is the other. The body's own sentence ("a record column can shadow any one of them") already implies it; the boundary paragraph should name it. Same inference limit, same skip direction, same remedy (an explicit provenance signal, scoped out); no real declaration has either shape. Not blocking on that basis.
No third construction found: every other seeded key is name-refused, the record leg refuses every column, and the console door seeds nothing the name legs miss.
5. Merge and honesty
- Merge with
origin/mainatcc5b3dd0c:--remerge-diffempty; 9 files against the main parent; the PR's content diff is byte-identical across the merge (0 lines of difference). - Pins: 24/24 at the new head. Re-run here after the
specrebuild:@objectstack/service-automationfull suite 111 files / 1340 tests passed (vitest exit 0, captured to file, 0FAILlines). - The dev's exit-code discipline (single command, redirect,
$?immediately) is the right reading of the trap I fell into myself in round 1.
Limits / NOT MEASURED: @objectstack/runtime full suite not re-run (the PR's runtime files are unchanged in revision 3); eslint, typecheck, the 60 green gates and the new check:dts-closure, @objectstack/mcp / dogfood / approvals; the "0 red before the fixtures" claim (reasoned only); the two exit-3 gates not re-run this round (confirmed at both earlier heads). Every reading before the spec rebuild ran with structuralConditionRefusal undefined in dist — provably off the screen-judging path (registerFlow would otherwise have thrown) — and the pins and my constructions were re-run after the rebuild with the same results.
Reviewer: domain:services PM seat (session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909), clause-② contract review, round 3.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code
…rding The sentence was imprecise both ways: overstated, because an OPTIONAL row-id valued field is not collected interactively — it continues with the value bound, it simply does not count toward the caller-named condition; and understated, because values equal to a caller-overridden recordId or alias, or to a shadowing recordId column, are refused too. Replaced in both shipped places with one wording, identical in each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
PM verification of the revision-4 fix-up — undrafted and armedVerified independently at head "No source change" proved by blob hash, not by the diffstat. Against revision 3 (
with Fences on the merge-base diff, all 0:
The two closes1. The cost sentence is genuinely identical across both shipped files — extracted from each and compared: 345 characters, byte-equal, with a deliberately-impossible variant as the control returning 0 length. The half that mattered was the overstatement, and it was in the changeset, which feeds release notes: revision 3 claimed a row-id-valued field is "always collected interactively", when an optional one continues with the value bound. A reader would have expected a prompt they will not get. The understated half (values equal to a caller-overridden 2. The boundary section names both constructions and calls both what they are. It now reads "it skips rather than pauses — said plainly, in both of the shapes that reach it", then separates them by how the alias candidate is lost: an object-less action whose record carries a column literally named What this PR's history is actually worth recordingFour passes, and each one found a construction the previous had not — X1 at the ⭐ Worth keeping separately: the dev found the fixture defect against its own tests and reported it rather than quietly fixing it. Dropping ⛔ Still Undrafted and auto-merge armed. Generated by Claude Code |
Part of #15705
Part of, not a closing keyword, deliberately. The card lists three expectations; this PR carries two of them. The third — aresume_runverb on the MCP tool surface — expands a published, authorizable surface and needs a maintainer ruling this card does not carry, so it is left open rather than closed by a merge. This PR does not make every screen flow completable over MCP: a call that omits the inputs still parks, and nothing on that surface can continue it.The defect
An
ai.exposedaction whose target is a screen flow could be started over MCP and never finished.run_actionseeds the flow'sisInputvariables from the caller'sparams—seedFlowActionParamsdoes that correctly, and it is untouched here — and the screen node suspended anyway. The MCP tool set has no resume verb, so the run parked forever.ai.exposedmeant "the agent can invoke this", not "the agent can complete this", and the fallback an agent then takes (create_record+update_recordto re-implement the flow's tail) bypasses whatever business rules the flow encapsulated.Two independent causes, fixed independently.
Fix 1 — a screen the caller already answered does not pause
packages/services/service-automation. The pause decision read only "does the node declare fields" and the author'swaitForInputflag; whether those fields were already bound was never consulted. It now is, through one pure verdict inscreen-input-contract.ts— the module that already owns the screen field contract, so "present" cannot drift into two meanings between this seam and the resume door (both reach the samevalidateScreenInputs, where a blank string is absent).The screen is treated as answered only when all of these hold:
requiredfield has a value from that caller. A value from the trigger record, from a prior node or from a declareddefaultValuedoes not answer arequiredfield; optional fields may come from anywhere.visibleWhenthe caller left unanswered. Enforced here and deliberately NOT on the resume door: the server has no rendered form and no collected values, so it cannot evaluate the predicate, and refusing costs only a pause — which is what the screen does today anyway. The resume door makes the opposite call for the opposite reason (enforcing a hidden field'srequiredthere dead-ends a run at Submit).Two screens never take this path because they declare nothing to satisfy: a message-only screen (no fields), and any screen whose author wrote
waitForInput: true— that flag is an explicit "show this", and a confirmation step is not something a params bag may skip.waitForInput: falsestays the wrong tool for the headless case, exactly as the card says: it skips the form for interactive users too.What "the caller supplied it" has to disprove
The params bag the engine receives is not the caller's bag. It carries two dispatcher seeds, and both must be refused before "the key is in
params" can mean anything.The record.
seedFlowActionParamsspreads the subject row into the bag, so every column is there whether the caller named it or not. Refused by: the record has no such key, orparamsholds a different value than the record's.The row id, which is not a column. Both doors put the launched row's id into the bag under names no record leg can see. The trigger door (
buildAutomationContext, serving the console's{recordId, objectName, params}POST) seedsrecordIdand the camelCase object-id alias —crmLeadIdforcrm_lead— and sets nocontext.recordat all; the actions door seeds those two plus the action's declaredrecordIdParam. Refused two ways:recordId, and the alias rebuilt fromcontext.objectexactly as the doors build it;recordIdParam, whose name is action-level metadata this executor cannot see. The dispatcher seeds the same row id under every id key it knows, so the id is recoverable from the bag itself. Three candidates are read, and each is separately load-bearing — a record column can shadow any one of them, becauseseedFlowActionParamswrites a key onlyif (seeded[key] === undefined)and the record spread came first:params.recordId— the only candidate that survives a non-defaultrecordIdField, which is precisely the shape that skipped in revision 2;recordId;record.id— which covers an object-less action, where no alias is derivable.Every refusal above fails toward pausing.
recordIdor<object>Id, or when its value equals what the bag carries underrecordId,<object>Id, orrecord.id(normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen.That sentence is stated three times — here, in the changeset and in
flows.mdx— and the three copies are identical, which is the point of stating a cost three times. (Revision 3's version was wrong both ways: it claimed an optional row-id-valued field is always collected interactively, when it continues with the value bound, and it omitted the caller-override and column-shadowing shapes, which are also refused.)The boundary of inference
Three revisions found three layers of the same defect: this module infers what the caller meant, and every dispatcher seed it does not know about re-opens the question. What remains after the three candidates is the case where every candidate is shadowed at once, and it skips rather than pauses — said plainly, in both of the shapes that reach it. Both need a non-default
recordIdField(so the row id is notrecord.id) and arecordIdParamnaming a key the record lacks; they differ only in how the alias candidate is lost:recordId;recordIdand the alias key itself — e.g. acrmLeadIdcolumn oncrm_lead.Inference cannot close either; only an explicit caller-provenance signal can, and that is an
AutomationContextcontract change deliberately scoped out of this card (open question B on #15705). A reviewer looked for a third construction and found none; no declaration in the repo has either shape.Why minimal: no new authorable key, no contract change, no new
AutomationContextfield, no touch toseedFlowActionParams, and no change to any path that does not enter a flat screen node with fields.waitForInputkeeps every meaning it had.suspended-run-storejudges against aJSON.parsed context, so a later wizard screen colliding with a non-scalar column (array/object) of the trigger record can read as caller-supplied. Scalar columns and un-paused runs are unaffected. Filed as #15812; the remedy is value comparison rather than identity, deliberately not made here.Fix 2 —
list_actionspublishes a flow action's inputspackages/runtime.summarizeActionParamsiteratedaction.paramsonly. Atype: 'flow'action almost never declaresparams— its input contract is the target flow'sisInputvariables, which is what the caller's bag binds into — so every flow action listed with noparamskey at all, while the tool description promised "its input parameters". The reported reproduction passeddue_datewhere the flow declaresdueDatebecause guessing was the only move available.The flow's
isInputvariables are now surfaced in declaration order, carrying thelabel/type/required/optionsof the screen field that collects each one, gathered across all screen nodes (a wizard collects its inputs over several steps; stopping at the first screen would publish a subset while looking complete).requiredcomes from the screen field alone — a flow variable has norequiredkey, and inferring one from "declares nodefaultValue" would invent a contract the author never wrote.Why minimal: second, never first — an author's own
action.paramsstill wins outright, so this can only fill a silence. The projection is pure and the caller resolves the flow (domains/mcp.tsasks the automation service's already-declared optionalgetFlow), so a service without it, a target the registry does not hold, and agetFlowthat throws all degrade to exactly today's answer.Census: what else reaches the pause decision
Searched, not recalled (
git grepforshouldPause,waitForInput,type: 'screen',registerNodeExecutor,executor.execute).AutomationEngine.executeNodeis the only call site of anyexecutor.execute()the engine acts on (engine.ts), stated as such at thesupportsPauseguard. Everything below funnels through it.execute()→traverseNext/runRegion(loop and parallel region bodies).resumeInternalcontinues withtraverseNextpast the node; only amap:correlation re-runs the node, which a screen never carries. A wizard's later screen is entered fresh, which is where the durable-store limit above applies.registerNodeAlias's executor delegates to the canonical executor and returns its result, so an alias ofscreeninherits the new behaviour with the same guards.supportsPauseenforcement (refuseUndeclaredSuspension) gates everysuspend: true;screendeclaressupportsPause: true/resumeAuthority: 'any'and is unaffected.run_actionbridge and the RESTPOST /api/v1/actions/...door (both throughdispatchFlowAction),POST /automation/:name/trigger(buildAutomationContext— the console's door), record-change and scheduled triggers, and subflow / map child runs.paramsto the same object it sets asrecord(record-change-trigger.ts), so the bag is emphatically not empty; every key is identity-equal to the record's own value, which is what refuses it. Pinned as such, because that is the leg the durable-store limit weakens.waitForInput—packages/cli's i18n extractor (static analysis over flow metadata) and the spec config contract. Neither reads a runtime pause decision.evaluateCondition,validateFlowExpressions, the predicate/ledger surfaces,seedFlowActionParams,packages/mcp,packages/spec, andcontent/docs/releases/.Verification
Exit codes captured by redirect after a single command (
cmd > log 2>&1; EXIT=$?) — never through a pipe, and never read off a multi-command line, where$?belongs to the last command; where a tool prints its own verdict line, that line is what is quoted. Exit 3 = NOT MEASURED, 2 = NOT WIRED, 124 = timeout kill, 137 = OOM — none read as a pass.Revision 4 (head
2041951a1, prose only). The gate family re-derived at this head — no stale-tree warning, same 62 gates over the same 9 paths. 59 exit 0, and every gate that reads either changed file is among them:check:changeset-gate-self-tests,check:pm-governed-prose,check:doc-anchors,check:doc-authoring,check:docs-single-h1,check:docs-redirects,check:docs-image-tag,check:docs-audit-scope,check:corpus-claim-drift,check:published-readme-links,check:objectui-changeset,check:partof-closing-keyword,check:nul-bytes.check:docs-transcript-drift— the docs gate most directly on this diff — first answered exit 3 in this unbuilt worktree ("@objectstack/lintis not built … NOT a pass and NOT a finding"), so@objectstack/lintwas built at--concurrency=1and it re-ran exit 0 (405 pages, 4 declared transcript values).node scripts/check-adr-0087-registration.mjs --base origin/main --head 2041951a1→ exit 0 ("adds no declared-breaking changeset"), with--self-test→ exit 0 as its control. The three remaining exit 3 = NOT MEASURED arecheck:dts-closure,check:dual-build-cjs-loadsandcheck:published-readme-exports, all of which read built output of every workspace package; none is reachable from a prose diff, and CI builds the workspace.Revision 3 (head
c31fdc8c7) — behaviour readings, unchanged, because no source file moved since.@objectstack/service-automation(full)@objectstack/runtime(full)eslint . --no-inline-config(repo-wide, not narrowed)typecheckfor both packages (incl.check:test-typecheck)Green at earlier revisions and unaffected since:
@objectstack/mcp26 files / 289 tests,@objectstack/plugin-approvals37 files / 665 tests,@objectstack/dogfood131 files / 1019 tests + 3 skipped.Mutation checks — 16, all at revision 3's head. Each leg proved on disk: the
HEADblob hash read before, the mutation refused unlessgit hash-objectchanged, the restore viagit checkout HEAD -- ABSOLUTE_PATHproved by the hash returning to the HEAD blob and an emptygit diff HEAD, under atrap … EXIT INT TERMwith an absolute repo root.⭐ One of these was a finding against this PR's own tests. The
tokenpin alone is satisfied by any of the three row-id candidates, so droppingparams.recordId— the exact line revision 3 was asked to add — left all 21 green. The pin covered the leg but not its parts. Three fixtures were added, each answerable by exactly one candidate, and each candidate now has its own red.params.recordIdcandidateparams.recordIdcan refuse this one" (was 0 before the fixtures were strengthened)record.idcandidaterecord.idcan refuse this one"recordIdParampins and all three candidate fixturesshouldPauseto its pre-change formwaitForInput: truegetFlowwire onlyisInputfilterNo rebuild leg is claimed and none is owed: every mutated subject is reached through relative source imports from its test, and service-automation's
vitest.config.tsdeclares exactly one alias (@objectstack/platform-objects), so nothing under test resolves through a packageexportstodist. For thedogfoodsuite — which does resolve throughdist— both packages were rebuilt and the new symbols confirmed present there before that suite ran.Docs and changeset
content/docs/automation/flows.mdxgains two sections beside the existing screen-resume rules, and.changeset/screen-flow-headless-satisfaction.mdis aminoron both packages. Both state the row-id refusal, the accepted cost (in the wording above, identical in both) and the durable-store limit; neither claims "a console run supplies none of the screen's fields". Nocontent/docs/references/orcontent/docs/releases/edit;packages/specuntouched, so none of its generated artifacts move.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y